Analyzing the Utah Bikeways data

Comparing the bikeways by type with the highway mileage for each city

This analysis calculates and compares roadway lane miles and bikeway facility miles across Utah municipalities using spatial data from the Utah Geospatial Resource Center (UGRC).
Author
Affiliation

Pukar Bhandari

Published

November 16, 2025

1 Introduction

This analysis examines the distribution of roadway and bikeway infrastructure across Utah municipalities. The primary objectives are to:

  1. Calculate total road lane miles for each municipality
  2. Calculate bikeway lane miles by facility type for each municipality
  3. Compare roadway and bikeway infrastructure across municipalities

All spatial data is sourced from the Utah Geospatial Resource Center (UGRC) and processed using the R spatial ecosystem.

2 Setup Environment

2.1 Install Packages

The following packages are required for this analysis. Install them if not already available in your R environment.

2.2 Load Packages

Show the code
library(dplyr) # for data manipulation

Attaching package: 'dplyr'
The following objects are masked from 'package:stats':

    filter, lag
The following objects are masked from 'package:base':

    intersect, setdiff, setequal, union
Show the code
library(tidyr) # for tidy data operations (pivot functions)
library(readr) # read and write tabular data
library(stringr) # manipulate strings
library(forcats) # process categorical data
library(fs) # for file system management
library(sf) # for simple feature geometries
Linking to GEOS 3.13.1, GDAL 3.11.0, PROJ 9.6.0; sf_use_s2() is TRUE
Show the code
library(arcgislayers) # to read arcgis rest services
library(janitor) # clean column names

Attaching package: 'janitor'
The following objects are masked from 'package:stats':

    chisq.test, fisher.test
Show the code
library(units) # set and adjust units of measurement
udunits database from C:/Users/Pukar.Bhandari/AppData/Local/R/win-library/4.5/units/share/udunits/udunits2.xml
Show the code
library(tmap) # view sf objects

2.3 Environment Variables

We use EPSG:3566 (NAD83(HARN) / Utah North) projection, which uses US survey feet as the base unit. This projection is appropriate for statewide analysis in Utah and ensures accurate distance calculations.

Show the code
# Set Project CRS
PROJECT_CRS = "EPSG:3566"

# Set tmap mode to interactive
tmap::tmap_mode("view")
ℹ tmap modes "plot" - "view"
ℹ toggle with `tmap::ttm()`

3 Read Data from UGRC

All spatial datasets are downloaded from UGRC’s ArcGIS REST services. To improve performance and enable offline work, the data is cached locally as GeoDatabase files. The code checks if local copies exist before downloading.

Show the code
# set data folder
dir_data <- "_data"
fs::dir_create(dir_data) # Create a directory if it doesnt exist

3.1 Utah City Boundaries

Municipal boundaries are used to aggregate roadway and bikeway data by city. These boundaries represent the legal limits of incorporated municipalities in Utah.

Show the code
# Define paths
path_ut_cities <- file.path(dir_data, "UGRC", "UtahMunicipalBoundaries.gdb")

# Download if not exists
if (!fs::file_exists(path_ut_cities)) {
  # Read data using arcgis package
  sf_ut_cities <- arcgislayers::arc_read(
    "https://services1.arcgis.com/99lidPhWCzftIe9K/ArcGIS/rest/services/UtahMunicipalBoundaries/FeatureServer/0",
    crs = PROJECT_CRS
  ) |> janitor::clean_names()
  # Write to a GeoDatabase
  sf_ut_cities |>
    sf::st_write(path_ut_cities, layer = "UtahMunicipalBoundaries", append = FALSE)
} else {
  # Read from local copy
  sf_ut_cities <- sf::st_read(path_ut_cities) |>
    sf::st_transform(PROJECT_CRS)
}
Reading layer `UtahMunicipalBoundaries' from data source 
  `D:\GitHub\GIS-Bikeway-by-City\_data\UGRC\UtahMunicipalBoundaries.gdb' 
  using driver `OpenFileGDB'
Simple feature collection with 259 features and 17 fields
Geometry type: MULTIPOLYGON
Dimension:     XY
Bounding box:  xmin: 934991.8 ymin: 6077738 xmax: 2272474 ymax: 7898798
Projected CRS: NAD83 / Utah Central (ftUS)
Show the code
tmap::qtm(sf_ut_cities)

3.2 Utah County Boundaries

County boundaries are used to aggregate roadway and bikeway data for unincorporated areas in Utah.

Show the code
# Define paths
path_ut_counties <- file.path(dir_data, "UGRC", "UtahCountyBoundaries.gdb")

# Download if not exists
if (!fs::file_exists(path_ut_counties)) {
  # Read data using arcgis package
  sf_ut_counties <- arcgislayers::arc_read(
    "https://services1.arcgis.com/99lidPhWCzftIe9K/ArcGIS/rest/services/UtahCountyBoundaries/FeatureServer/0",
    crs = PROJECT_CRS
  ) |> janitor::clean_names()
  # Write to a GeoDatabase
  sf_ut_counties |>
    sf::st_write(path_ut_counties, layer = "UtahCountyBoundaries", append = FALSE)
} else {
  # Read from local copy
  sf_ut_counties <- sf::st_read(path_ut_counties) |>
    sf::st_transform(PROJECT_CRS)
}
Reading layer `UtahCountyBoundaries' from data source 
  `D:\GitHub\GIS-Bikeway-by-City\_data\UGRC\UtahCountyBoundaries.gdb' 
  using driver `OpenFileGDB'
Simple feature collection with 29 features and 13 fields
Geometry type: MULTIPOLYGON
Dimension:     XY
Bounding box:  xmin: 895000.7 ymin: 6076316 xmax: 2358017 ymax: 7905124
Projected CRS: NAD83 / Utah Central (ftUS)
Show the code
tmap::qtm(sf_ut_cities)

3.3 Utah Roadways

The Utah Roads dataset contains all roadway centerlines statewide, including the number of through lanes for each road segment. This forms the basis for calculating road lane miles.

Show the code
# Define paths
path_ut_roads <- file.path(dir_data, "UGRC", "UtahRoads.gdb")

# Download if not exists
if (!fs::file_exists(path_ut_roads)) {
  # Read data using arcgis package
  sf_ut_roads <- arcgislayers::arc_read(
    "https://services1.arcgis.com/99lidPhWCzftIe9K/ArcGIS/rest/services/UtahRoads/FeatureServer/0",
    crs = PROJECT_CRS
  ) |> janitor::clean_names()
  # Write to a GeoDatabase
  sf_ut_roads |>
    sf::st_write(path_ut_roads, layer = "UtahRoads", append = FALSE)
} else {
  # Read from local copy
  sf_ut_roads <- sf::st_read(path_ut_roads) |>
    sf::st_transform(PROJECT_CRS)
}
Reading layer `UtahRoads' from data source 
  `D:\GitHub\GIS-Bikeway-by-City\_data\UGRC\UtahRoads.gdb' using driver `OpenFileGDB'
Simple feature collection with 410604 features and 86 fields
Geometry type: MULTILINESTRING
Dimension:     XY
Bounding box:  xmin: 895206.4 ymin: 6071653 xmax: 2356671 ymax: 7904448
Projected CRS: NAD83 / Utah Central (ftUS)
Show the code
tmap::qtm(sf_ut_roadways)

3.4 Utah Bikeways

The Bikeways dataset contains all existing bicycle facilities in Utah. Each segment has two facility type attributes (facility1 and facility2) representing the bicycle facility in each direction of travel.

Show the code
# Define paths
path_ut_bikeways <- file.path(dir_data, "UGRC", "Bikeways.gdb")

# Download if not exists
if (!fs::file_exists(path_ut_bikeways)) {
  # Read data using arcgis package
  sf_ut_bikeways <- arcgislayers::arc_read(
    "https://services1.arcgis.com/99lidPhWCzftIe9K/ArcGIS/rest/services/Bikeways/FeatureServer/0",
    crs = PROJECT_CRS
  ) |> janitor::clean_names()
  # Write to a GeoDatabase
  sf_ut_bikeways |>
    sf::st_write(path_ut_bikeways, layer = "Bikeways", append = FALSE)
} else {
  # Read from local copy
  sf_ut_bikeways <- sf::st_read(path_ut_bikeways) |>
    sf::st_transform(PROJECT_CRS)
}
Reading layer `Bikeways' from data source 
  `D:\GitHub\GIS-Bikeway-by-City\_data\UGRC\Bikeways.gdb' using driver `OpenFileGDB'
Simple feature collection with 101693 features and 12 fields
Geometry type: MULTILINESTRING
Dimension:     XY
Bounding box:  xmin: 896123 ymin: 6076384 xmax: 2356440 ymax: 7904448
Projected CRS: NAD83 / Utah Central (ftUS)
Show the code
tmap::qtm(sf_ut_bikeways)

4 Road Lane Miles by Municipality

4.1 Add Unincorporated Areas

Show the code
tmap::qtm(sf_ut_cities, col = "blue", fill = NULL, size = 1) +
  tmap::qtm(sf_ut_counties, col = "red", fill = NULL, size = 2)
Show the code
# Create combined municipal boundaries including unincorporated areas
sf_ut_municipal <- dplyr::bind_rows(
  # Add incorporated cities
  sf_ut_cities |>
    dplyr::select(Name = name),

  # Add unincorporated county areas
  sf_ut_counties |>
    dplyr::mutate(
      # Convert county name to proper case and add "(Unincorporated)"
      Name = paste0(stringr::str_to_title(name), " County (Unincorporated)")
    ) |>
    dplyr::select(Name) |>
    # Remove areas that overlap with cities
    sf::st_difference(
      sf_ut_cities |>
        sf::st_union()
    )
)
Warning: attribute variables are assumed to be spatially constant throughout
all geometries
Show the code
# View the result
sf_ut_municipal
Simple feature collection with 288 features and 1 field
Geometry type: GEOMETRY
Dimension:     XY
Bounding box:  xmin: 895000.7 ymin: 6076316 xmax: 2358017 ymax: 7905124
Projected CRS: NAD83 / Utah Central (ftUS)
First 10 features:
          Name                          SHAPE
1       Newton MULTIPOLYGON (((1503755 784...
2       Eureka MULTIPOLYGON (((1472271 715...
3   Huntsville MULTIPOLYGON (((1566912 762...
4   Springdale MULTIPOLYGON (((1209138 615...
5  Grantsville MULTIPOLYGON (((1349646 739...
6    Bluffdale MULTIPOLYGON (((1516737 735...
7     Blanding MULTIPOLYGON (((2224889 630...
8     Herriman MULTIPOLYGON (((1484817 736...
9        Levan MULTIPOLYGON (((1539952 700...
10   Panguitch MULTIPOLYGON (((1368834 638...
Show the code
tmap::qtm(sf_ut_municipal, fill = NULL)

4.2 Calculate Lane Miles

Road lane miles are calculated by multiplying the length of each road segment by the number of through lanes. For road segments where the through lane count is missing (NA), we assume 1 lane as a conservative estimate.

The calculation converts the geometry length from US survey feet to miles using the units package, which properly handles unit conversions and maintains unit awareness throughout the analysis.

Show the code
sf_ut_roads <- sf_ut_roads |>
    dplyr::mutate(
        # TODO: fix thrulanes data
        dot_thrulanes = dplyr::if_else(is.na(dot_thrulanes), 1, dot_thrulanes),
        miles = units::set_units(sf::st_length(`SHAPE`), "mile"),
        lane_miles = units::set_units(dot_thrulanes * miles, "mile")
    )

sf_ut_roads |>
  dplyr::select(dot_thrulanes, miles, lane_miles) |>
  head(20)
Simple feature collection with 20 features and 3 fields
Geometry type: MULTILINESTRING
Dimension:     XY
Bounding box:  xmin: 1525086 ymin: 6823427 xmax: 1690992 ymax: 7058250
Projected CRS: NAD83 / Utah Central (ftUS)
First 10 features:
   dot_thrulanes             miles        lane_miles
1              1 1.52909874 [mile] 1.52909874 [mile]
2              1 2.86144968 [mile] 2.86144968 [mile]
3              1 1.06293998 [mile] 1.06293998 [mile]
4              1 0.80867386 [mile] 0.80867386 [mile]
5              1 0.71336950 [mile] 0.71336950 [mile]
6              1 0.36598669 [mile] 0.36598669 [mile]
7              1 0.09770893 [mile] 0.09770893 [mile]
8              1 0.25556167 [mile] 0.25556167 [mile]
9              1 1.16984782 [mile] 1.16984782 [mile]
10             1 0.30950636 [mile] 0.30950636 [mile]
                            SHAPE
1  MULTILINESTRING ((1580305 6...
2  MULTILINESTRING ((1582439 6...
3  MULTILINESTRING ((1585991 6...
4  MULTILINESTRING ((1655301 7...
5  MULTILINESTRING ((1548359 6...
6  MULTILINESTRING ((1614298 6...
7  MULTILINESTRING ((1606261 7...
8  MULTILINESTRING ((1606826 6...
9  MULTILINESTRING ((1525086 6...
10 MULTILINESTRING ((1616278 6...

4.3 Aggregate by Municipality

Using spatial join, we associate each road segment with its municipality and sum the lane miles. Roads that fall outside municipal boundaries will have NA for city_name.

Show the code
# Calculate total lane miles by municipality
road_miles_by_city <- sf_ut_roads |>

    # Spatial join: attach each road segment to the city it falls within
    sf::st_join(
      sf_ut_municipal,
      join = sf::st_intersects
    ) |>

    # Group and summarize: total lane miles per city
    dplyr::group_by(Name) |>
    dplyr::summarize(
      total_lane_miles = sum(lane_miles, na.rm = TRUE),
      .groups = "drop"
    ) |>

    # Output as a non-spatial table
    sf::st_drop_geometry()

# View the results
road_miles_by_city |>
  dplyr::arrange(dplyr::desc(total_lane_miles))
# A tibble: 285 × 2
   Name                              total_lane_miles
   <chr>                                       [mile]
 1 Millard County (Unincorporated)              8827.
 2 San Juan County (Unincorporated)             8138.
 3 Grand County (Unincorporated)                7153.
 4 Uintah County (Unincorporated)               6586.
 5 Box Elder County (Unincorporated)            5612.
 6 Tooele County (Unincorporated)               5603.
 7 Iron County (Unincorporated)                 5527.
 8 Garfield County (Unincorporated)             4872.
 9 Emery County (Unincorporated)                4680.
10 Juab County (Unincorporated)                 4583.
# ℹ 275 more rows

Key Finding: Salt Lake City has the highest road lane miles among Utah municipalities, followed by St. George and West Valley City. The NA category represents roads outside municipal boundaries (unincorporated areas, state highways between cities, etc.).

5 Bikeway Miles by Municipality

5.1 Reclassify Bikeway Types to Categories

We use forcats package to respect the hierarchy of the bicycle facilities.

Show the code
# Define the order of facility classes
facility_class_order <- c(
  "Paved Path",
  "Protected Bike Lane",
  "Bike Lane",
  "Marked Route",
  "Unmarked Route",
  "Other"
)

# Reclassify bike facilities into broader categories as factors
sf_ut_bikeways <- sf_ut_bikeways |>
  dplyr::mutate(
    facility1_class = dplyr::case_when(
      facility1 == "Trail or Pathway" ~ "Paved Path",
      facility1 %in% c(
        "Cycle track, at-grade, protected with parking (1A)",
        "Cycle track, protected with barrier (1B)",
        "Cycle track, raised and curb separated (1C)"
      ) ~ "Protected Bike Lane",
      facility1 %in% c(
        "Buffered bike lane (2A)",
        "Bike lane (2B)"
      ) ~ "Bike Lane",
      facility1 %in% c(
        "Marked shared roadway (3B)",
        "Signed shared roadway (3C)"
      ) ~ "Marked Route",
      facility1 == "Shoulder bikeway (3A)" ~ "Unmarked Route",
      TRUE ~ "Other"
    ),
    facility2_class = dplyr::case_when(
      facility2 == "Trail or Pathway" ~ "Paved Path",
      facility2 %in% c(
        "Cycle track, at-grade, protected with parking (1A)",
        "Cycle track, protected with barrier (1B)",
        "Cycle track, raised and curb separated (1C)"
      ) ~ "Protected Bike Lane",
      facility2 %in% c(
        "Buffered bike lane (2A)",
        "Bike lane (2B)"
      ) ~ "Bike Lane",
      facility2 %in% c(
        "Marked shared roadway (3B)",
        "Signed shared roadway (3C)"
      ) ~ "Marked Route",
      facility2 == "Shoulder bikeway (3A)" ~ "Unmarked Route",
      TRUE ~ "Other"
    ),
    # Convert to factors with proper ordering
    facility1_class = factor(facility1_class, levels = facility_class_order),
    facility2_class = factor(facility2_class, levels = facility_class_order)
  )

5.2 Calculate Bikeway Lane Miles by Facility Type

Bikeway facilities are directional - a single bikeway segment can have different facility types in each direction (facility1 and facility2). For this analysis, we treat each direction as a separate lane, meaning a 1-mile bikeway segment with bike lanes in both directions counts as 2 lane-miles of bike lanes.

The pivot_longer() function stacks facility1 and facility2 into separate rows, effectively doubling the mileage for segments where both directions have facilities. We then group by municipality and facility type to calculate total lane miles for each combination.

Show the code
# Prepare bikeways with both directions as lanes
bikeway_miles_by_city <- sf_ut_bikeways |>
    dplyr::filter(cartocode != "9") |> # Remove 'sidewalks' and 'virtual link' connections
    dplyr::select( facility1_class, facility2_class) |>

    # Add mileage to each bikeway segment
    dplyr::mutate(
      miles = units::set_units(sf::st_length(SHAPE), "mile")
    ) |>

    sf::st_join(
      sf_ut_municipal,
      join = sf::st_intersects
    ) |>

    # Drop geometry from dataframe for further processing
    sf::st_drop_geometry() |>

    # Treat facility1 and facility2 as separate lane directions
    tidyr::pivot_longer(
      cols = c(facility1_class, facility2_class),
      values_to = "facility_class"
    ) |>

    # Apply weighting: 1.0 for bidirectional (Paved Path), 0.5 for directional facilities
    dplyr::mutate(
      weighted_miles = dplyr::if_else(
        facility_class == "Paved Path",
        as.numeric(miles) * 0.5, # TODO: change this to 1 if needed
        as.numeric(miles) * 0.5
      )
    ) |>

    # Summarize total lane miles by city and facility class
    dplyr::group_by(Name, facility_class) |>
    dplyr::summarize(
      total_lane_miles = sum(as.numeric(weighted_miles), na.rm = TRUE),
      .groups = "drop"
    ) |>

    # Make facility class into columns
    tidyr::pivot_wider(
      names_from = facility_class,
      values_from = total_lane_miles,
      values_fill = 0
    )

# Add total across all bike lane types
bikeway_miles_by_city <- bikeway_miles_by_city |>
    dplyr::rowwise() |>
    dplyr::mutate(
      `All Biking Facilities` = sum(dplyr::c_across(!Name))
    ) |>
    dplyr::ungroup()

# View the results
bikeway_miles_by_city |>
  dplyr::arrange(dplyr::desc(`All Biking Facilities`))
# A tibble: 285 × 8
   Name           `Paved Path` `Bike Lane` `Unmarked Route` Other `Marked Route`
   <chr>                 <dbl>       <dbl>            <dbl> <dbl>          <dbl>
 1 Garfield Coun…       17.3         0.954             55.1 2637.          0    
 2 San Juan Coun…        0.483       0                130.  2219.          0    
 3 Millard Count…        0           0                 40.7 2054.          0    
 4 Grand County …       11.4         0.257             43.1 1437.          0    
 5 Uintah County…        0           1.44             114.  1337.          0    
 6 Box Elder Cou…        0           0                 35.4 1285.          0    
 7 Tooele County…        2.75        1.72              52.6 1231.          0    
 8 Duchesne Coun…        0           0                 54.1 1216.          0.700
 9 Juab County (…        0           0                 45.1 1176.          0    
10 Beaver County…        2.05        0                 22.7 1130.          0    
# ℹ 275 more rows
# ℹ 2 more variables: `Protected Bike Lane` <dbl>,
#   `All Biking Facilities` <dbl>

Key Finding: Bikeway infrastructure varies significantly by facility type across municipalities. The dataset includes various facility types such as bike lanes, protected bike lanes, shared-use paths, and trails.

6 Combine dataframes

6.1 Create Multimodal Summary

The final step combines road lane miles and bikeway lane miles by facility type into a single dataset. This allows for direct comparison of roadway and bikeway infrastructure across municipalities.

Each row represents a municipality, with columns for total road lane miles followed by columns for each bikeway facility type. Cities with zero bikeway miles for a particular facility type will show 0 in that column.

Show the code
# Combine with roads
combined_miles_by_city <- road_miles_by_city |>
  # Convert road lane miles to numeric
  dplyr::mutate(`All Roads` = as.numeric(total_lane_miles)) |>
  dplyr::select(Name, `All Roads`) |>
  # Join with bikeway data
  dplyr::right_join(bikeway_miles_by_city, by = "Name") |>
  # Reorder columns: Name, bike categories, total bike, then road
  dplyr::select(
    Name,
    dplyr::all_of(facility_class_order),   # bike lane categories
    - Other,
    # `All Biking Facilities`,
    `All Roads`
  ) |>
  # Round all numeric columns to one decimal
  dplyr::mutate(dplyr::across(where(is.numeric), \(x) round(x, 1)))

combined_miles_by_city
# A tibble: 285 × 7
   Name          `Paved Path` `Protected Bike Lane` `Bike Lane` `Marked Route`
   <chr>                <dbl>                 <dbl>       <dbl>          <dbl>
 1 Alpine                15.1                     0         0.7              0
 2 Alta                   0                       0         0                1
 3 Altamont               0                       0         0                0
 4 Alton                  0                       0         0                0
 5 Amalga                 0.4                     0         0                0
 6 American Fork          9.7                     2         6.9              0
 7 Annabella              0                       0         0                0
 8 Antimony               0                       0         0                0
 9 Apple Valley           0                       0         0                0
10 Aurora                 0                       0         0                0
# ℹ 275 more rows
# ℹ 2 more variables: `Unmarked Route` <dbl>, `All Roads` <dbl>

Key Finding: The ratio of bikeway lane miles to road lane miles varies considerably across Utah municipalities, indicating different levels of investment in bicycle infrastructure relative to overall roadway networks.

7 Export Final results

The analysis results are exported as a CSV file for further use in reports, visualizations, or other analyses. The output directory is created automatically if it doesn’t exist.

Show the code
# set data folder
dir_output <- "_output"
fs::dir_create(dir_output) # Create a directory if it doesnt exist
Show the code
combined_miles_by_city |>
    readr::write_csv(
        file.path(dir_output, "miles_by_city.csv"),
        append = FALSE
    )
TipDownload the output files:

8 Conclusion

This analysis provides a comprehensive inventory of roadway and bikeway infrastructure across Utah municipalities. The methodology is reproducible and can be updated as new data becomes available from UGRC. The spatial join approach ensures accurate attribution of infrastructure to municipalities, though users should note that roads and bikeways in unincorporated areas will appear under the NA category.